5 Applying Complex Transformations & Interactions¶
In the previous chapter, you learned how to draw a custom seating chart with tribunes using SwiftUI’s Path
. However, quite a few things are still missing. Users must be able to preview the seats inside a tribune and select them to purchase tickets. To make the user’s navigation through the chart effortless and natural, you’ll implement gesture handling, such as dragging, magnifying and rotating.
As usual, fetch the starter project for this chapter from the materials, or continue where you left off in the previous chapter.
Open SportFan.xcodeproj and head straight to SeatingChartView
.
Manipulating SwiftUI Shapes Using CGAffineTransform¶
You need two things to display seats for each tribune: a Shape
containing the Path
drawing the seat and a CGRect
representing its bounds. To accomplish the former, create a new struct
named SeatShape
:
struct SeatShape: Shape {
func path(in rect: CGRect) -> Path {
Path { path in
}
}
}
The shape you’re about to draw consists of a few parts: the seat’s back, squab, and rod connecting them. Start by defining a few essential properties right below inside the Path
’s trailing closure:
let verticalSpacing = rect.height * 0.1
let cornerSize = CGSize(
width: rect.width / 15.0,
height: rect.height / 15.0
)
let seatBackHeight = rect.height / 3.0 - verticalSpacing
let squabHeight = rect.height / 2.0 - verticalSpacing
let seatWidth = rect.width
To emulate the top-to-bottom perspective, you calculate the seat back rectangle as slightly shorter vertically than the squab.
Then, right below these variables, define the CGRect
’s for the back and squab and draw the corresponding rounded rectangles:
let backRect = CGRect(
x: 0, y: verticalSpacing,
width: seatWidth, height: seatBackHeight
)
let squabRect = CGRect(
x: 0, y: rect.height / 2.0,
width: seatWidth, height: squabHeight
)
path.addRoundedRect(in: backRect, cornerSize: cornerSize)
path.addRoundedRect(in: squabRect, cornerSize: cornerSize)
Now, draw the rod:
path.move(to: CGPoint(
x: rect.width / 2.0,
y: rect.height / 3.0
))
path.addLine(to: CGPoint(
x: rect.width / 2.0,
y: rect.height / 2.0
))
You still have a long way to go before looking at the seat’s shape as part of a tribune. To get a quick preview for the time being, create a new struct
called SeatPreview
:
struct SeatPreview: View {
let seatSize = 100.0
var body: some View {
ZStack {
SeatShape().path(in: CGRect(
x: 0, y: 0,
width: seatSize, height: seatSize
))
.fill(.blue) // 1
SeatShape().path(
in: CGRect(
x: 0, y: 0,
width: seatSize, height: seatSize
))
.stroke(lineWidth: 2) // 2
}
.frame(width: seatSize, height: seatSize)
}
}
This process is similar to the shapes you’ve drawn in the previous chapter:
- Inside a
ZStack
, you use one instance ofSeatShape
as a background with.blue
fill. - You use the second shape’s instance to draw the seat’s stroke.
Finally, you must make Xcode show the SeatPreview
in the previews window. Create a new PreviewProvider
:
struct Seat_Previews: PreviewProvider {
static var previews: some View {
SeatPreview()
}
}
Your seat preview should look like this, for the time being:
The seat is there but looks relatively flat. You’ll skew it back to give it a slightly more realistic perspective. Don’t forget that you drew the tribunes all around the stadium field, which means the seats should always face the center of the field. Head to the next section to learn how to transform shapes!
Matrices Transformations¶
Check Path
‘s API, and you’ll notice there are many methods, such as addRoundedRect
or addEllipse
, accepting an argument of type CGAffineTransform
called transform
. Via just one argument, you can manipulate a subpath in 2D space in several ways: rotate, skew, scale or translate.
As you might have guessed from its prefix, CGAffineTransform
is part of Apple’s Core Graphics framework, which still comes in handy in SwiftUI.
CGAffineTransform
is essentially a 3x3 matrix:
You’ll work with the parameters a
, b
, c
, d
, tx
and ty
. The third column stays unchanged regardless of the transformations you apply - 0
, 0
and 1
.
An identity matrix is one that SwiftUI applies to a subpath by default. It performs no transformations when multiplying to another matrix:
When you want to apply an offset to an object, you need a translation matrix, where tx
represents the shift along the x-axis, and ty
moves the object along the y-axis:
A scaling operation is similar as well, having only two defining parameters, sx
and sy
:
You do, however, need to use a
, b
, c
and d
to make a rotation matrix to rotate an object counterclockwise by angle a
:
Finally, skewing an object requires applying the b
or c
parameters of a transformation matrix, where b
skews the subpath along the y-axis, and c
affects the x-axis:
Now, knowing all the transformation possibilities matrices offer you, you can sketch out your action plan:
- First, skew the seat back along the x-axis.
- Then rotate the entire seat’s shape by an angle it accepts from the outside to face the stadium field.
- Finally, translate the seat’s shape to negate the translation effect from previously rotating it since SwiftUI rotates a subpath around its
(minX, minY)
point. The shape will appear to rotate around its center point without shifting sideways.
Applying the Skewing Operation¶
Back in SeatShape
’s Path
, find the seatWidth
you previously added, and add the following line above it:
let skewAngle = .pi / 4.0
Next, you need to calculate how much further along the x-axis the seat back goes after being skewed. You’ll account for this measurement when defining seatWidth
, thus making the whole shape fit into rect
. Add the following variable right below skewAngle
:
let skewShift = seatBackHeight / tan(skewAngle)
To calculate the value of skewShift
, you use a mathematical formula to find the adjacent in the right triangle by the angle’s tangent.
Now, update seatWidth
:
let seatWidth = rect.width - skewShift
Next, update the rod’s final point to connect it to the center of the squab. Replace:
path.addLine(to: CGPoint(
x: rect.width / 2.0,
y: rect.height / 2.0
))
With:
path.addLine(to: CGPoint(
x: rect.width / 2.0 - skewShift / 2,
y: rect.height / 2.0
))
Here comes the exciting part! Above the addRoundedRect
invocations, create a matrix to skew the seat back:
let skew = CGAffineTransform(
a: 1, b: 0, c: -cos(skewAngle), // 1
d: 1, tx: skewShift + verticalSpacing, ty: 0
) // 2
Here are two crucial points:
- You use
CGAffineTransform(a:b:c:d:tx:ty:)
to build a matrix on your own. You update thec
value to skew the seat back along the x-axis. The minus in front of thecos
of the angle defines the direction of skewing. You set it to skew the object towards the right side. - Since SwiftUI transforms an object around its origin point, you shift the
x
value to keep the shape inside therect
’s bounds.
Finally, add the transform to the backSeat
rounded rectangle. Replace:
path.addRoundedRect(in: backRect, cornerSize: cornerSize)
With:
path.addRoundedRect(
in: backRect,
cornerSize: cornerSize,
transform: skew
)
Take a look at the Seat
preview:
Rotating the Seat¶
To allow SeatShape
to rotate, add a new property to the struct
:
let rotation: CGFloat
To verify the rotation functionality in the preview, add a rotation
property to SeatPreview
:
@State var rotation: Float = 0.0
Pass the rotation value to the initializers of both shapes:
SeatShape(rotation: CGFloat(-rotation))
Then, wrap the root view in the preview’s body
into a VStack
. Then add a Slider
and a Text
:
VStack {
// ZStack with SeatShape's
Slider(value: $rotation, in: 0.0...(2 * .pi), step: .pi / 20)
Text("\(rotation)")
}.padding()
Now, return to SeatShape
and apply a rotation matrix to the path
by mutating the existing final path:
path = path.applying(CGAffineTransform(rotationAngle: rotation))
Well, that was easy, wasn’t it? Check out the preview and play around with the rotation slider:
Oh, it shouldn’t fly around, though! :]
Rotating an Object Around an Arbitrary Point¶
Applying a rotation matrix rotates an object around its origin (minX, minY)
. To perform the transformation around an arbitrary point like its center, you first need to shift the object to that point, perform the rotation and then translate the object back.
First, define the rotation point by adding the following variable at the very bottom of Path { }
before applying the rotation transformation:
let rotationCenter = CGPoint(x: rect.width / 2, y: rect.height / 2)
Now, create the first translation matrix to shift the seat to the rotation point:
let translationToCenter = CGAffineTransform(
translationX: rotationCenter.x,
y: rotationCenter.y
)
Additionally, you need a translation matrix to move the seat inside the rect
’s bounds:
let initialTranslation = CGAffineTransform(
translationX: rect.minX,
y: rect.minY
)
Now, apply the transformations step-by-step. Create a variable to keep the result of the first multiplication:
var result = CGAffineTransformRotate(translationToCenter, rotation)
Instead of directly multiplying the translationToCenter
and the rotation matrix, you use CGAffineTransformRotate
to apply a transformation on the translationToCenter
matrix and get the result.
To translate the seat back, use CGAffineTransformTranslate
as follows:
result = CGAffineTransformTranslate(result, -rotationCenter.x, -rotationCenter.y)
Finally, apply the result of multiplying initialTranslation
and result
to the path, and assign it to the path by replacing the last line:
path = path.applying(result.concatenating(initialTranslation))
Pay attention to the order of the matrices concatenation. In terms of matrices, a * b != b * a
!
Check out the preview and move the slider’s knob around a bit to make sure the seat rotates around its center:
That was a bit of a challenge. Great job! Next, to calculate the bounds for each seat in all the rectangular tribunes.
Locating Rectangular Tribunes’ Seats¶
With your animation’s performance in mind, you’ll ensure the seat locations are computed only once, assigned to the respective tribune and drawn only when a user picks a specific tribune. Otherwise, it would be a waste to draw each one when they’re barely visible due to the scale of the seating chart.
Create a new struct to hold a seat’s path:
struct Seat: Hashable, Equatable {
var path: Path
public func hash(into hasher: inout Hasher) {
hasher.combine(path.description)
}
}
You conform Seat
to Hashable
to iterate over a tribune’s seats to display them. Later, you’ll enable users to pick a specific seat, so being Equatable
will also come in handy.
Go to the Sector
shape and create a new method:
private func computeSeats(for tribune: CGRect, at rotation: CGFloat) -> [Seat] {
var seats: [Seat] = []
// TODO
return seats
}
This method will eventually calculate the bounds for the seats based on the CGRect
of the tribune and the rotation.
Start by defining all the necessary values, such as size, the number of horizontal and vertical seats and spacings. Add these lines in the // TODO
above:
let seatSize = tribuneSize.height * 0.1
let columnsNumber = Int(tribune.width / seatSize)
let rowsNumber = Int(tribune.height / seatSize)
let spacingH = CGFloat(tribune.width - seatSize * CGFloat(columnsNumber)) / CGFloat(columnsNumber)
let spacingV = CGFloat(tribune.height - seatSize * CGFloat(rowsNumber)) / CGFloat(rowsNumber)
Below the variables you’ve just added, create two loops to iterate over all the seats:
(0..<columnsNumber).forEach { column in
(0..<rowsNumber).forEach { row in
}
}
Inside the inner loop, calculate the origin points for each seat and build a CGRect
:
let x = tribune.minX + spacingH / 2.0 + (spacingH + seatSize) * CGFloat(column)
let y = tribune.minY + spacingV / 2.0 + (spacingV + seatSize) * CGFloat(row)
let seatRect = CGRect(
x: x, y: y,
width: seatSize, height: seatSize
)
Finally, create a SeatShape
, pass the rotation to it and append Seat
to the array:
seats.append(Seat(
path: SeatShape(rotation: rotation)
.path(in: seatRect)
)
)
Displaying the Seats¶
To access each tribune’s seats when rendering the seating chart, add a new property to Tribune
:
var seats: [Seat]
Now, find makeRectTribuneAt(x:y:rotated:)
and update its declaration to include a rotation
parameter.
private func makeRectTribuneAt(
x: CGFloat, y: CGFloat,
vertical: Bool, rotation: CGFloat
) -> Tribune {
Note that you also removed the default value for vertical
, so you’ll need to provide this in all invocations, or the compiler will throw an error. You’ll handle that shortly.
Now, create a variable for the tribune’s CGRect
inside the method:
let rect = CGRect(
x: x,
y: y,
width: vertical ? tribuneSize.height : tribuneSize.width,
height: vertical ? tribuneSize.width : tribuneSize.height
)
Use rect
to instantiate Tribune
and calculate the seats by updating the return
statement:
return Tribune(
path: RectTribune().path(in: rect),
seats: computeSeats(for: rect, at: rotation)
)
Now, the compiler will be unhappy about some missing arguments. To sort it out, pass an empty array as the last parameter to the arc tribune initializer.
At the bottom of computeArcTribunesPaths(at:corner:)
:
tribunes.append(Tribune(path: ArcTribune(
/* arc tribune's properties */
).path(in: CGRect.zero), seats: []))
Then, pass the correct rotations to the makeRectTribuneAt
invocations in computeRectTribunesPaths(at:corner:)
. You compute the top and bottom horizontal tribunes in the (0..<tribunesNumberH).forEach
loop, so pass 0
and -.pi
as rotation
respectively:
tribunes.append(makeRectTribuneAt(
x: x,
y: rect.minY + offset,
vertical: false,
rotation: 0
))
tribunes.append(makeRectTribuneAt(
x: x, y: rect.maxY - offset - tribuneSize.height,
vertical: false,
rotation: -.pi
))
For the vertical tribunes, pass -.pi / 2.0
and 3.0 * -.pi / 2.0
:
tribunes.append(makeRectTribuneAt(
x: rect.minX + offset,
y: y,
vertical: true,
rotation: -.pi / 2.0
))
tribunes.append(makeRectTribuneAt(
x: rect.maxX - offset - tribuneSize.height,
y: y,
vertical: true,
rotation: 3.0 * -.pi / 2.0
))
Finally, you can display the selected tribune’s seats! Go to SeatingChartView
’s body
and add the following code after the tribunes ForEach
:
if let selectedTribune {
ForEach(selectedTribune.seats, id: \.self) { seat in
ZStack {
seat.path.fill(.blue)
seat.path.stroke(.black, lineWidth: 0.05)
}
}
}
Run the app and select any of the non-arced tribunes:
Next, you’ll work on the arc tribune’s seats!
Computing Positions of the Arc Tribune’s Seats¶
Calculating the bounds of an arc tribune’s seats is similar to building an arc tribune’s Path
. Since you move along an arc, not a straight line, you operate with angles. You used an angle value for a tribune and another for the spacing. In the same way, you’ll calculate the angle needed for a seat and the spacing between neighboring seats.
To implement it, create a new method inside Sector
:
private func computeSeats(for arcTribune: ArcTribune) -> [Seat] {
var seats: [Seat] = []
// TODO
return seats
}
An arc tribune has seat columns of the same size, but the rows shrink toward the stadium field. So, define the “static” variables right away in the method, instead of the // TODO
mark:
let seatSize = tribuneSize.height * 0.1
let rowsNumber = Int(tribuneSize.height / seatSize)
let spacingV = CGFloat(tribuneSize.height - seatSize * CGFloat(rowsNumber)) / CGFloat(rowsNumber)
Now, add the outer loop to iterate over the rows:
(0..<rowsNumber).forEach { row in
}
Inside the loop, add variables that will dynamically change depending on the row:
let radius = arcTribune.radius - CGFloat(row) * (spacingV + seatSize) - spacingV - seatSize / 2.0 // 1
let arcLength = abs(arcTribune.endAngle - arcTribune.startAngle) * radius // 2
let arcSeatsNum = Int(arcLength / (seatSize * 1.1)) // 3
Here’s a code breakdown:
- For each row, you calculate the radius of a circle. You’ll place the row’s seats along an arc of this circle, just as you did when drawing the arc tribunes’ outlines.
- You multiply the difference between the tribune’s
endAngle
andstartAngle
by the radius to produce the length of the corresponding arc. - Based on the length of the arc, you calculate the number of seats in the row. You multiply
seatSize
by1.1
to give a slight spacing between the seats.
Now, add some more variables:
let arcSpacing = (arcLength - seatSize * CGFloat(arcSeatsNum)) / CGFloat(arcSeatsNum) // 1
let seatAngle = seatSize / radius // 2
let spacingAngle = arcSpacing / radius // 3
var previousAngle = arcTribune.startAngle + spacingAngle + seatAngle / 2.0 // 4
Here’s a code breakdown:
- To calculate the spacing, you deduct the sum of all seat sizes from the arc length and divide the result by the number of seats.
- Dividing
seatSize
byradius
gives you the angle needed for each seat. AlthoughseatSize
is the measurement of a seat along a straight line, you need an arc measurement for the formula. The difference between them is negligible in this case. - Applying the same formula, you calculate the angle needed for the spacing between the seats.
previousAngle
contains the latest offset along the arc, and you’ll update it after each seat’s calculations.
Create an inner loop below the variables:
(0..<arcSeatsNum).forEach { _ in
}
Inside the inner loop, calculate the “center” of each seat based on previousAngle
:
let seatCenter = CGPoint(
x: arcTribune.center.x + radius * cos(previousAngle),
y: arcTribune.center.y + radius * sin(previousAngle)
)
With the approach above, you’ll iteratively move along the arc, centering the seats precisely on the arc.
Knowing the seat’s center, you can calculate its origin and bounds:
let seatRect = CGRect(
x: seatCenter.x - seatSize / 2,
y: seatCenter.y - seatSize / 2,
width: seatSize,
height: seatSize
)
Create a Seat
and append it to the array:
seats.append(
Seat(
path: SeatShape(rotation: previousAngle + .pi / 2)
.path(in: seatRect)
)
)
Since the seats’ angles are perpendicular to the angle of the tribune, meaning you drew tribunes from left to right, but you draw the seats from the tribune’s top to bottom, you need to add .pi / 2
to previousAngle
.
Right below, update previousAngle
:
previousAngle += spacingAngle + seatAngle
Finally, back in computeArcTribunesPaths(at:corner:)
, find the code where you insantiate the Tribune
(i.e. let tribune = ...
) and update it with your new computeSeats(for:)
method, like so:
let arcTribune = ArcTribune(
center: center,
radius: radius,
innerRadius: innerRadius,
startingPoint: startingPoint,
startingInnerPoint: startingInnerPoint,
startAngle: previousAngle + spacingAngle,
endAngle: previousAngle + spacingAngle + angle
)
let tribune = Tribune(
path: arcTribune.path(in: CGRect.zero),
seats: computeSeats(for: arcTribune)
)
Run the app and try to pick a tribune:
Nice! It’s now time to let the user actually interact with the seats.
Processing User Gestures¶
Navigating through the seating chart is somewhat cumbersome and extremely limited right now. Users should be as free with gestures as possible to speed up a tribune and seat selection.
SwiftUI offers a variety of gesture handlers, most of which are valuable for the seating chart.
Dragging¶
To obtain the offset value from the user’s drag gesture, you’ll use SwiftUI’s DragGesture
. First, add these new properties to SeatingChartView
:
@GestureState private var drag: CGSize = .zero
@State private var offset: CGSize = .zero
@GestureState
is a property wrapper that keeps drag
up-to-date when the gesture that is ongoing and will reset it to its initial state once the user is done. The offset
property keeps the latest value between the gestures to avoid resetting it.
Since CGSize
is the measurement for a drag gesture, add a handy extension to ease CGSize
s concatenation:
extension CGSize {
static func +(left: CGSize, right: CGSize) -> CGSize {
return CGSize(width: left.width + right.width, height: left.height + right.height)
}
}
Add one more property to SeatingChartView
:
var dragging: some Gesture {
DragGesture()
.updating($drag) { currentState, gestureState, transaction in // 1
gestureState = currentState.translation
}
.onEnded { // 2
offset = offset + $0.translation
}
}
Here’s a code breakdown:
- SwiftUI invokes the
.updating
callback repeatedly while the gesture is in progress.currentState
contains the latesttranslation
value, and changinggestureState
updates thedrag
property. - Once the gesture is over,
.onEnded
is invoked. There you update theoffset
property to ensure the chart stays in place once the user lifts their finger.
Then, below .rotationEffect
of SeatingChartView
, add .offset
:
.offset(offset + drag)
Finally, right below .offset
, attach the drag gesture handler using .simultaneousGesture
:
.simultaneousGesture(dragging)
SwiftUI can handle multiple gestures at the same time. Use .simultaneousGesture
to indicate that you’d like to enable more than one gesture giving them equal priority.
Currently, you have two gesture handlers: dragging
and the tap gesture handler for the tribunes. You’ll add a few more soon. Now, when you run the app, the chart is easily draggable:
Zooming¶
Like DragGesture
, you can use MagnificationGesture
to obtain the current gesture’s scale.
Add a new property to SeatingChartView
:
@GestureState private var manualZoom = 1.0
Then, create a gesture handler:
var magnification: some Gesture {
MagnificationGesture()
.updating($manualZoom) { currentState, gestureState, transaction in
gestureState = currentState
}
.onEnded {
zoom *= $0
}
}
Now, update .scaleEffect
and move it above .rotationEffect
:
.scaleEffect(manualZoom * zoom, anchor: zoomAnchor)
Finally, attach the gesture handler below dragging
:
.simultaneousGesture(magnification)
Run the app and try to zoom the chart.
If you run it on a simulator, hold the Option (⌥) key and drag the chart with your mouse to emulate a magnification gesture.
Rotating¶
The 2D rotating gesture is as easily implemented in SwiftUI as the others. You know what to do! Add another @GestureState
property, and add a rotation
property keep track of the applied rotation:
@GestureState private var currentRotation: Angle = .radians(0.0)
@State var rotation = Angle(radians: .pi / 2)
Don’t forget about the corresponding gesture handler:
var rotationGesture: some Gesture {
RotationGesture()
.updating($currentRotation) { currentState, gestureState, transaction in
gestureState = .radians(currentState.radians)
}
.onEnded {
rotation += $0
}
}
Update .rotationEffect
of SeatingChartView
:
.rotationEffect(rotation + currentRotation, anchor: zoomAnchor)
Last but not least, add the gesture handler below the two previous ones:
.simultaneousGesture(rotationGesture)
That was a piece of cake, right? :] Next, you’ll implement seat selection and add some bells and whistles.
Handling Seat Selection¶
To keep track of the selected seats, add a new property to SeatingChartView
:
@State private var selectedSeats: [Seat] = []
A tap gesture to pick a tribune and one to pick a seat should be mutually exclusive: they can’t co-occur. Therefore, it makes sense to handle both in one gesture handler and decide which one should occur depending on the coordinates of the touch.
Remove .onTapGesture
from the tribune, and add a new .onTapGesture
to the ZStack
above .scaleEffect
:
.onTapGesture { tap in
if let selectedTribune, selectedTribune.path.contains(tap) {
// TODO pick a seat
} else {
// TODO pick a tribune
}
}
Now, if a user has already selected a tribune and the touch occurred inside its bounds, it’s safe to assume the user tapped a seat. Otherwise, they’ve chosen a tribune.
To handle seat selection, create a new method in SeatingChartView
:
private func findAndSelectSeat(at point: CGPoint, in selectedTribune: Tribune) {
guard let seat = selectedTribune.seats
.first(where: { $0.path.boundingRect.contains(point) }) else {
return
} // 1
withAnimation(.easeInOut) {
if let index = selectedSeats.firstIndex(of: seat) { // 2
selectedSeats.remove(at: index)
} else {
selectedSeats.append(seat)
}
}
}
Here’s a breakdown:
- First, you search for a seat containing the coordinates of the touch among the selected tribune’s seats. If there is none, you return immediately.
- Finally, you select or deselect the seat depending on whether the seat is present in
selectedSeats
.
Now, add another method to handle a tribune selection:
private func findAndSelectTribune(at point: CGPoint, with proxy: GeometryProxy) {
let tribune = tribunes.flatMap(\.value)
.first(where: { $0.path.boundingRect.contains(point) })
let unselected = tribune == selectedTribune
let anchor = UnitPoint(
x: point.x / proxy.size.width,
y: point.y / proxy.size.height
)
LinkedAnimation.easeInOut(for: 0.7) {
zoom = unselected ? 1.25 : 25
}
.link(
to: .easeInOut(for: 0.3) {
selectedTribune = unselected ? nil : tribune
zoomAnchor = unselected ? .center : anchor
offset = .zero
},
reverse: !unselected
)
}
Like the seat selection, you first search for the tribune containing the needed coordinates. After that, you proceed the way you have since the previous chapter, except the offset
is reset to .zero
when zooming in or out.
Update .onTapGesture
to invoke the newly created methods:
if let selectedTribune, selectedTribune.path.contains(tap) {
findAndSelectSeat(at: tap, in: selectedTribune)
} else {
findAndSelectTribune(at: tap, with: proxy)
}
Now update a seat’s .fill
depending on whether the user has selected it. Replace:
seat.path.fill(.blue)
With:
seat.path.fill(selectedSeats.contains(seat) ? .green : .blue)
As the last step, remove .coordinateSpace
from the ZStack
. Now all touch events occur in the same view, so there’s no need to convert the coordinate space.
Check the preview or run the app:
You’re so close to the finish line with only a few things left to polish.
Final Animating Touches¶
Since a seat is essentially a Path
, just like a tribune, it’s pretty easy to animate it by trimming it. Add a new property of type CGFloat
to SeatingChartView
:
@State private var seatsPercentage: CGFloat = .zero
Find seat.path
and trim the seat’s stroke and fill:
seat.path
.trim(from: 0, to: seatsPercentage)
.fill(selectedSeats.contains(seat) ? .green : .blue)
seat.path
.trim(from: 0, to: seatsPercentage)
.stroke(.black, lineWidth: 0.05)
Go back to findAndSelectTribune
and add the following line below anchor
:
seatsPercentage = selectedTribune == nil || !unselected ? 0.0 : 1.0
Now, the animation will reset every time you select a new tribune.
Additionally, update seatsPercentage
in the first of the two linked animations, right below zoom
:
seatsPercentage = unselected ? 0.0 : 1.0
Check it out:
To make SeatsSelectionView
aware of the changes happening in SeatingChartView
, add the following @Binding
properties to SeatingChartView
:
@Binding var zoomed: Bool
@Binding var selectedTicketsNumber: Int
Increment or decrement selectedTicketsNumber
in findAndSelectSeat(at:in:)
inside withAnimation
accordingly:
if let index = selectedSeats.firstIndex(of: seat) {
selectedTicketsNumber -= 1
selectedSeats.remove(at: index)
} else {
selectedTicketsNumber += 1
selectedSeats.append(seat)
}
Then, update zoomed
in MagnificationGesture
’s .onEnded
callback. Add this code below zoom *= $0
:
withAnimation {
zoomed = zoom > 1.25
}
In findAndSelectTribune(at:with:)
in the first linked animation, add:
zoomed = !unselected
Then, to zoom out and reset the chart, if zoomed
gets updated from SeatsSelectionView
, add .onChange
to the ZStack
in SeatingChartView
:
.onChange(of: zoomed) {
if !$0 && zoom > 1.25 {
LinkedAnimation.easeInOut(for: 0.7) {
zoom = 1.25
seatsPercentage = 0.0
}
.link(
to: .easeInOut(for: 0.3) {
selectedTribune = nil
zoomAnchor = .center
offset = .zero
},
reverse: false
)
}
}
Update the preview of SeatingChartView
to include the new initializer arguments:
SeatingChartView(
zoomed: Binding.constant(false),
selectedTicketsNumber: Binding.constant(5)
)
Finally, return to SeatSelectionView
, and add these @State
properties:
@State private var stadiumZoomed = false
@State private var selectedTicketsNumber: Int = 0
@State private var ticketsPurchased: Bool = false
Update SeatingChartView
’s initializer:
SeatingChartView(
zoomed: $stadiumZoomed,
selectedTicketsNumber: $selectedTicketsNumber
)
Now, wrap the inner VStack
containing the team name and the cart icon into the if-statement
, and add a .transition
:
if !stadiumZoomed {
VStack { ... }
.transition(.move(edge: .top))
}
Now when a user zooms on the chart, the title and icon go out of sight to make the screen less cluttered.
To indicate the number of selected tickets, wrap the cart icon in a ZStack
, and add a label:
ZStack(alignment: .topLeading) {
/* cart icon */
if selectedTicketsNumber > 0 {
Text("\(selectedTicketsNumber)")
.foregroundColor(.white)
.font(.caption)
.background {
Circle()
.fill(.red)
.frame(width: 16, height: 16)
}
.alignmentGuide(.leading) { _ in -20}
.alignmentGuide(.top) { _ in 4 }
}
}
Using the alignment guides, you adjust the label to appear on the top right corner of the icon.
To reset the gestures quickly, add a zoom-out button below the Buy Tickets button and wrap both into an HStack
:
HStack {
/* Buy Tickets button */
if stadiumZoomed {
Button {
withAnimation {
stadiumZoomed = false
}
} label: {
Image("zoom_out")
.resizable()
.scaledToFit()
.frame(width: 48, height: Constants.iconSizeL)
.clipped()
.background {
RoundedRectangle(cornerRadius: 36)
.fill(.white)
.frame(width: 48, height: 48)
.shadow(radius: 2)
}
.padding(.trailing)
}
}
}
Update the action of the Buy Tickets button:
if selectedTicketsNumber > 0 {
ticketsPurchased = true
}
Finally, you need to show a pop-up to tell the user that the purchase was successful. Add .confirmationDialog
to the root view, right below background(Constants.orange, ignoresSafeAreaEdges: .all)
:
.confirmationDialog(
"You've bought \(selectedTicketsNumber) tickets.",
isPresented: $ticketsPurchased,
actions: { Button("Ok") {} },
message: { Text("You've bought \(selectedTicketsNumber) tickets. Enjoy your time at the game!")}
)
Ta-da! You’ve done it! Run the app to see the final result:
Key Points¶
CGAffineTransform
represents a transformation matrix, which you can apply to a subpath to perform rotation, scaling, translating or skewing.- A transformation matrix in 2D graphics is of size 3x3, where the first two columns are responsible for all the applied transformations. The last one is constant to preserve the matrices’ concatenation ability.
- An object rotates around its origin when manipulated by a rotation matrix. To use a different point as an anchor, move the object towards that point first, apply the desired rotation and then shift it back.
- SwiftUI can process multiple gestures, like
DragGesture
,MagnificationGesture
,RotationGesture
orTapGesture
, simultaneously when you attach them with the.simultaneousGesture
modifier.
Where to Go From Here?¶
Transformation matrices are still universally used in computer graphics regardless of the programming language, framework or platform. Learning them once will be handy when working with animations outside the Apple ecosystem. The Wikipedia article on the topicoffers a good overview of transformation matrices as a mathematical concept, also in the context of 2D or 3D computer graphics.
Additionally, if matrices don’t scare but excite you, and you want to dive deep into Metal, Apple’s low-level computer graphics framework, Metal by Tutorials can guide you step-by-step along your journey.